Day 25:小結:Flow 資料流工具箱總覽,準備進測試與收斂期 結尾拋出一個問題:如果想確定一段協程程式碼在各種時間差、取消時機下的行為都符合預期,而且不想每次驗證都得真的等上老半天,該怎麼做。今天要正式回答這個問題,也正式開啟系列第五階段,「測試、陷阱與實戰整合」。
系列走到這裡,驗證程式碼是否正確的方式,其實一直停留在同一個層次,把程式碼跑起來,看終端機印出的訊息像不像預期。協程行為牽涉暫停、取消、時間差,光靠肉眼觀察一次執行結果,很難有信心保證各種情境下都正確,也很難重複驗證。
協程需要專屬的測試方式。Kotlin 協程官方測試框架提供的核心工具,正是虛擬時間排程器與對應的測試進入點,這兩者正是今天要定案的內容。
先看看不使用專門測試工具時,具體會卡在哪個環節。
回到多來源圖片下載與聚合工具。假設某個下載函式內部設計了一段邏輯,逾時多久沒回應,就視為這次下載失敗:
suspend fun downloadWithTimeout(url: String): Result<ByteArray> {
return try {
val bytes = withTimeout(5_000) {
downloadImage(url)
}
Result.success(bytes)
} catch (e: TimeoutCancellationException) {
Result.failure(e)
}
}
想寫一個測試驗證這段逾時邏輯是否正確,如果測試程式碼裡真的呼叫 delay 等待超過五秒,這個測試就得真實地等上那麼久才能跑完。一個測試等五秒看起來還能忍受,但一個專案裡如果有幾十個涉及逾時、重試、輪詢間隔的類似情境,等待時間全部加總起來,整個測試套件執行起來會又慢又痛苦,跑一次完整測試可能得泡杯咖啡等結果,長期下來也沒有人願意常常執行它。
問題的核心在於,測試真正需要驗證的是「邏輯在時間經過之後的行為是否正確」,而不是「真的把這段時間經歷過一遍」。這兩件事之間存在明顯落差。一般的測試工具,包括常見的單元測試框架本身,並不知道協程裡的 delay 其實可以被跳過,它們只會老實地讓執行緒等待對應的時間過去。要打破這個限制,需要一個真正懂得介入協程時間感知方式的機制,而不是在既有測試工具外面隨便包一層協程啟動語法就能解決。
這正是 TestCoroutineScheduler 存在的理由。TestCoroutineScheduler 是 Kotlin 協程測試框架提供的一種排程器,在測試環境下接管協程對時間的感知。程式碼裡呼叫 delay 時,不需要真的讓執行緒閒置等待那段時間,而是由這個排程器用虛擬的方式,直接把時間推進到該發生的那一刻,讓後續程式碼幾乎立即繼續執行。
回到下載函式逾時判斷的測試情境,套用虛擬時間排程器之後,測試程式碼裡即使寫了等待超過五秒逾時的邏輯,實際執行測試時幾乎是瞬間完成。因為這段時間是被排程器推進的,不是真的被度過的。開發者可以放心針對各種時間相關的情境撰寫測試,不必擔心測試套件因此變得又慢又難維護,原本得等五秒才能看到結果的逾時測試,現在毫秒等級就能跑完。
這個排程器不只能一口氣跳過整段等待時間,也能讓測試精確控制目前時間推進到第幾秒,藉此驗證某個時間點協程的狀態應該是什麼。這裡的效果有點像遙控器的快轉鍵,把整段錄影快轉過去,只在需要確認的那個畫面停下來看,而不是把整段影片從頭到尾看過一遍。今天只建立這個方向性認識,時間推進的具體語法留到實際需要精細控制時間點時再展開,不在本篇一次講完。
知道排程器的存在還不夠,這個排程器要被安裝進一個測試裡,才能真正發揮作用。這正是 runTest 要扮演的角色。
runTest 是撰寫協程測試時使用的測試進入點,作用類似 Day 04:launch、async、runBlocking,三種 Coroutine Builder 該選誰 定案的 Coroutine Builder 之於一般協程程式碼的角色。在 runTest 建立的範圍裡,內建的就是套用了虛擬時間排程器的協程環境,開發者不需要自己手動組裝這個排程器,寫下 runTest { ... },裡面的 delay 就已經享受著虛擬時間推進的待遇。
這裡必須把 runTest 與 Day 4 定案的 runBlocking 對照清楚。兩者角色確實有些相似,都是讓外層的非協程程式碼,例如測試框架本身,能夠呼叫到內部的 suspend function,扮演銜接協程世界與非協程世界的橋樑。但 runTest 額外提供了虛擬時間推進的能力,這是 runBlocking 完全不具備的。如果在協程測試裡誤用 runBlocking,測試程式碼裡的 delay 依然會真實地阻塞執行緒等待,前面談的種種好處全部落空,等於白白繞了一圈又走回最初的痛點。兩者不可混用在協程測試情境,測試應該優先選用 runTest。
在 runTest 範圍內啟動的協程,同樣遵守系列建立起來的結構化並發框架,父子收斂與取消機制依然適用。這裡只點出這個呼應關係,細節不重新展開。
把前面談的內容接起來,針對下載逾時邏輯寫一段最小可用的測試:
@Test
fun `下載超過逾時時間應回傳失敗結果`() = runTest {
val result = downloadWithTimeout("https://example.com/slow.jpg")
assertTrue(result.isFailure)
}
假設 downloadImage 內部刻意模擬一段超過五秒的延遲,用來觸發逾時邏輯,這段測試程式碼裡依然可以照常寫 delay 或依賴逾時判斷,但整個測試執行起來幾乎是瞬間完成,不會真的卡住五秒才看到結果。這個對比,正是 TestCoroutineScheduler 與 runTest 搭配運作的最直接體現,測試邏輯完全沒有妥協,變快的只有執行時間。
取消行為的驗證同樣受惠於這套機制。Day 10:協程取消不是強制中斷,是一場合作 定案過協程取消機制,Cooperative Cancellation,說明取消要求發出後,協程需要在可取消點主動配合才會真正結束。測試某個協程被取消後是否正確停止工作,同樣可以在 runTest 範圍內驗證,不需要真的等待協程原本該跑多久才能觀察取消時機是否正確。這裡只點出這個延伸應用的方向,完整的取消測試範例不在本篇展開。
順帶一提,如果測試對象涉及 Flow,例如 Day 19:從回傳一個值到回傳一串值,Flow 是什麼 談過的持續發送多個值的資料流,同樣受惠於虛擬時間排程器控制發送時機的能力。這部分同樣只先建立印象,任何 Flow 專屬的測試工具細節超出本篇範疇,不在今天展開。
今天正式定案了 TestCoroutineScheduler 與 runTest 這兩個系列既定錨點。TestCoroutineScheduler 接管協程對時間的感知,讓 delay 或逾時等待不必真的被度過;runTest 是套用了這個排程器的協程測試進入點,角色類似 Coroutine Builder,但與 runBlocking 不可混用,協程測試情境應該優先選用 runTest。取消行為的驗證,也一併受惠於這套虛擬時間機制。
目前為止看到的都還是單一、獨立的小範例,一個下載函式、一段逾時邏輯、一個測試案例。如果換成一個具備多來源平行下載、資料流回報進度、例外處理這些真實複雜度的專案,測試該怎麼組織,前面五個階段累積的所有觀念,又該怎麼在同一個專案裡協同運作,這些疑問今天先不回答。
下一篇會正式把這些觀念收斂進一個具名的示範專案,圖片下載工具完整版,並附上簡短的跨平台示範。